package engine.gameObject.terrain;

import engine.gameObject.RenderObject;
import engine.gameObject.collider.TerrainCollider;
import engine.material.Material;
import engine.shader.Shader;
import engine.util.texture.TextureArrayLoader;
import engine.util.texture.TextureLoader;

import java.nio.FloatBuffer;
import java.util.HashMap;
import java.util.Map;

import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL30;

import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.opengl.GL15.*;
import static org.lwjgl.opengl.GL20.*;
import static org.lwjgl.opengl.GL30.*;

/**
 * This class represents a game object which is containing a terrain. It is
 * necessary, that the sides of the terrain has a power of two.
 * 
 * @author Richard
 * 
 */
public class Terrain extends RenderObject {
	public static final Material MATERIAL = new Material(new float[] { 0.6f,
			0.6f, 0.6f, 1 }, new float[] { 0.7f, 0.7f, 0.7f, 1 }, null, null, 0);

	private int width;
	private int depth;
	private int detailAreaSize; // the size of a area for loding.
	private int[][][] indicesStorage; // stores the indices for the different
										// levels of detail and their positions.
	private float[][] heightMap;
	private int[][] basicTerrainVAO;
	private int[][][] renderIndices;
	private int stride;

	private int highDetailArea = 2; // the area around the player, which is
									// drawn with high detail, minimum is 3
	private int highDetailStep = 1; // the steps, when the detail changes from
									// higher to lower

	private int lastAreaX = Integer.MIN_VALUE; // stores the last area location,
												// where the camera currently is
	private int lastAreaZ = Integer.MIN_VALUE; // stores the last area location,
												// where the camera currently is
	
	private float textureScale = 1; // states, how much the texture should be scaled along the terrain
	private float[] texHeights; //array, which states, at which height which texture should be used
	
	private int textureID; //the id of the used texture, or 0 otherwise
	private int textureType; //the type of the used texture. This type should be equal to to the type the texture id represents

	/**
	 * The (width - 1) and (height - 1) of the heightMap array must have power
	 * of two. The areaSize specifies the size of areas, for the level of
	 * detail. The areaSize must have a power of two too.
	 * 
	 * @param heightMap
	 * @param areaSize
	 */
	public Terrain(float[][] heightMap, int areaSize, float textureScale, float[] texHeights) {
		width = heightMap.length;
		depth = heightMap[0].length;
		this.heightMap = heightMap;
		this.detailAreaSize = areaSize;
		this.textureScale = textureScale;
		this.texHeights = texHeights;

		if (!hasCorrectSizes(width - 1, areaSize) || !hasCorrectSizes(depth - 1, areaSize)) {
			throw new NullPointerException(
					"The side + 1 must be dividable by areaSize");
		}
		
		if(!isPowerOfTwo(areaSize)) {
			throw new NullPointerException(
					"The sizes of areaSize must be power of two");
		}

		if (areaSize >= width || areaSize >= depth) {
			throw new NullPointerException(
					"Area size must be smaller than width and depth");
		}

		buildVBOs();
		
		setMaterial(MATERIAL);
		setCollider(new TerrainCollider(this));
		setColliding(true);
	}
	
	public Terrain(float[][] heightMap, int areaSize, float textureScale, String texSource) {
		this(heightMap, areaSize, textureScale, new float[0]);
		
		setTexture(texSource);
	}
	
	public Terrain(float[][] heightMap, int areaSize, float textureScale, String[] texSource) {
		this(heightMap, areaSize, textureScale, new float[0]);
		
		setTexture(texSource);
	}
	
	public Terrain(float[][] heightMap, int areaSize, float textureScale, String[] texSource, float[] texHeights) {
		this(heightMap, areaSize, textureScale, texHeights);
		
		setTexture(texSource);
	}

	/**
	 * Builds the VBO's for the several sections.
	 * 
	 * @param height
	 */
	private void buildVBOs() {
		stride = (3 + 3 + 3) * 4;
		
		int sizeX = width - 1;
		int sizeZ = depth - 1;
		int areaCountX = (width - 1) / detailAreaSize;
		int areaCountZ = (depth - 1) / detailAreaSize;

		float[][][] normals = computeNormals(heightMap);
		float[][][] octreePoints = new float[areaCountX][areaCountZ][2];

		float[] min = new float[3];
		float[] max = new float[3];

		//basicTerrainVBO = new int[areaCountX][areaCountZ];
		basicTerrainVAO = new int[areaCountX][areaCountZ];

		for (int i = 0; i < areaCountX; i++) {
			for (int j = 0; j < areaCountZ; j++) {
				FloatBuffer mapData = BufferUtils.createFloatBuffer(9
						* (detailAreaSize + 1) * (detailAreaSize + 1));

				octreePoints[i][j][0] = heightMap[i * detailAreaSize][j
						* detailAreaSize];
				octreePoints[i][j][1] = heightMap[i * detailAreaSize][j
						* detailAreaSize];

				for (int x = i * detailAreaSize; x <= (i + 1) * detailAreaSize; x++) {
					for (int z = j * detailAreaSize; z <= (j + 1)
							* detailAreaSize; z++) {
						mapData.put(x - width / 2);
						mapData.put(heightMap[x][z]);
						mapData.put(z - depth / 2);
						mapData.put(x / (float) sizeX * textureScale);
						mapData.put(z / (float) sizeZ * textureScale);
						mapData.put(getDepthTex(heightMap[x][z]));
						mapData.put(normals[x][z][0]);
						mapData.put(normals[x][z][1]);
						mapData.put(normals[x][z][2]);

						if (heightMap[x][z] < octreePoints[i][j][0]) {
							octreePoints[i][j][0] = heightMap[x][z];
						}

						if (heightMap[x][z] > octreePoints[i][j][1]) {
							octreePoints[i][j][1] = heightMap[x][z];
						}
					}
				}

				mapData.rewind();

				int vboID = glGenBuffers();
				glBindBuffer(GL_ARRAY_BUFFER, vboID);
				glBufferData(GL_ARRAY_BUFFER, mapData, GL_STATIC_DRAW);
				glBindBuffer(GL_ARRAY_BUFFER, 0);
				
				basicTerrainVAO[i][j] = glGenVertexArrays();
				glBindVertexArray(basicTerrainVAO[i][j]);
				
				glBindBuffer(GL_ARRAY_BUFFER, vboID);
				
				glEnableVertexAttribArray(0);
				glEnableVertexAttribArray(1);
				glEnableVertexAttribArray(2);
				
				glVertexAttribPointer(0, 3, GL_FLOAT, false, stride, 0);
				glVertexAttribPointer(1, 3, GL_FLOAT, false, stride, 12);
				glVertexAttribPointer(2, 3, GL_FLOAT, false, stride, 24);
				
				glBindBuffer(GL_ARRAY_BUFFER, 0);
				glBindVertexArray(0);

				mapData.clear();

				if (min[1] > octreePoints[i][j][0]) {
					min[1] = octreePoints[i][j][0];
				}

				if (max[1] < octreePoints[i][j][1]) {
					max[1] = octreePoints[i][j][1];
				}
			}
		}

		double height = max[1] - min[1];
		setBoundingRadius((float)Math.sqrt(width / 2 * width / 2 + height * height
				+ depth / 2 * depth / 2));

		createIndiciesList();

		renderIndices = new int[areaCountX][areaCountZ][3];
	}

	/**
	 * Creates the indices storage lists for LODing
	 */
	private void createIndiciesList() {
		indicesStorage = new int[6][][];

		for (int i = 1, index = 0; i < detailAreaSize; i *= 2, index++) {
			indicesStorage[index] = new int[9][2];

			indicesStorage[index][0][0] = IndicesCreator.getIndicies1(i,
					detailAreaSize);
			indicesStorage[index][0][1] = IndicesCreator.getLastLength();

			indicesStorage[index][1][0] = IndicesCreator.getIndicies2(i,
					detailAreaSize);
			indicesStorage[index][1][1] = IndicesCreator.getLastLength();

			indicesStorage[index][2][0] = IndicesCreator.getIndicies3(i,
					detailAreaSize);
			indicesStorage[index][2][1] = IndicesCreator.getLastLength();

			indicesStorage[index][3][0] = IndicesCreator.getIndicies4(i,
					detailAreaSize);
			indicesStorage[index][3][1] = IndicesCreator.getLastLength();

			indicesStorage[index][4][0] = IndicesCreator.getIndicies5(i,
					detailAreaSize);
			indicesStorage[index][4][1] = IndicesCreator.getLastLength();

			indicesStorage[index][5][0] = IndicesCreator.getIndicies6(i,
					detailAreaSize);
			indicesStorage[index][5][1] = IndicesCreator.getLastLength();

			indicesStorage[index][6][0] = IndicesCreator.getIndicies7(i,
					detailAreaSize);
			indicesStorage[index][6][1] = IndicesCreator.getLastLength();

			indicesStorage[index][7][0] = IndicesCreator.getIndicies8(i,
					detailAreaSize);
			indicesStorage[index][7][1] = IndicesCreator.getLastLength();

			indicesStorage[index][8][0] = IndicesCreator.getIndicies9(i,
					detailAreaSize);
			indicesStorage[index][8][1] = IndicesCreator.getLastLength();
		}

		indicesStorage[5] = new int[1][2];
		indicesStorage[5][0][0] = IndicesCreator.getIndicies5(detailAreaSize,
				detailAreaSize);
		indicesStorage[5][0][1] = IndicesCreator.getLastLength();
	}

	/**
	 * Computes the normals to the map.
	 * 
	 * @param height
	 * @return
	 */
	private float[][][] computeNormals(float[][] height) {
		int size = height.length - 1;
		float[][][] normals = new float[height.length][height.length][3];

		for (int posX = 0; posX < normals.length; posX++) {
			for (int posZ = 0; posZ < normals[posX].length; posZ++) {
				float x = 0.5f * (height[Math.max(0, posX - 1)][posZ] - height[Math
						.min(size, posX + 1)][posZ]);
				float y = 1;
				float z = 0.5f * (height[posX][Math.max(0, posZ - 1)] - height[posX][Math
						.min(size, posZ + 1)]);
				float length = (float) Math.sqrt(x * x + y * y + z * z);

				normals[posX][posZ][0] = x / length;
				normals[posX][posZ][1] = y / length;
				normals[posX][posZ][2] = z / length;
			}
		}

		return normals;
	}

	private boolean isPowerOfTwo(int number) {
		for (int i = 1; i <= number; i *= 2) {
			if (i == number) {
				return true;
			}
		}

		return false;
	}
	
	private boolean hasCorrectSizes(int terrainSize, int areaSize) {
		return terrainSize % areaSize == 0;
	}
	
	private float getDepthTex(float height) {
		if(texHeights == null || texHeights.length == 0) {
			return 0;
		}
		
		int i = 0;
		for(; i < texHeights.length; i++) {
			if(height < texHeights[i]) {
				if(Math.abs(height - texHeights[i]) < 0.1) {
					return i + 0.5f;
				}
				
				return i;
			}
		}
		
		return i;
	}

	@Override
	public void renderImpl(Shader shader) {
		updateLodingIndices();
		
		renderTerrain();
	}

	private void updateLodingIndices() {
		int x = (int) (getScene().getCurrentCamera().getEyeX() + (highDetailArea + 1)
				% 2 * detailAreaSize / 2 + width / 2)
				/ detailAreaSize;
		int z = (int) (getScene().getCurrentCamera().getEyeZ() + (highDetailArea + 1)
				% 2 * detailAreaSize / 2 + depth / 2)
				/ detailAreaSize;

		if (x != lastAreaX || z != lastAreaZ) {
			lastAreaX = x;
			lastAreaZ = z;

			float fx = x - (highDetailArea + 1) % 2 * 0.5f;
			float fz = z - (highDetailArea + 1) % 2 * 0.5f;

			for (int i = 0; i < renderIndices.length; i++) {
				for (int j = 0; j < renderIndices[i].length; j++) {
					boolean alreadySet = false;

					for (int detail = 0; detail < exponentOf(detailAreaSize); detail++) {
						if (Math.abs(fx - i) < highDetailArea / 2f + detail
								* highDetailStep
								&& Math.abs(fz - j) < highDetailArea / 2f
										+ detail * highDetailStep) {
							updateVBO(x, z, i, j, detail);
							alreadySet = true;
							break;
						}
					}

					if (!alreadySet) {
						renderIndices[i][j][0] = indicesStorage[5][0][0];
						renderIndices[i][j][1] = indicesStorage[5][0][1];
						renderIndices[i][j][2] = exponentOf(detailAreaSize);
					}
				}
			}
		}
	}

	private void updateVBO(int x, int z, int i, int j, int detail) {
		int borderRight = z + (highDetailArea - (highDetailArea + 1) % 2) / 2
				+ detail * highDetailStep;
		int borderLeft = z - highDetailArea / 2 - detail * highDetailStep;
		int borderTop = x + (highDetailArea - (highDetailArea + 1) % 2) / 2
				+ detail * highDetailStep;
		int borderBottom = x - highDetailArea / 2 - detail * highDetailStep;

		renderIndices[i][j][2] = detail;

		if (j == borderRight) {
			if (i == borderBottom) {
				renderIndices[i][j][0] = indicesStorage[detail][8][0];
				renderIndices[i][j][1] = indicesStorage[detail][8][1];
			} else if (i == borderTop) {
				renderIndices[i][j][0] = indicesStorage[detail][2][0];
				renderIndices[i][j][1] = indicesStorage[detail][2][1];
			} else {
				renderIndices[i][j][0] = indicesStorage[detail][5][0];
				renderIndices[i][j][1] = indicesStorage[detail][5][1];
			}
		} else if (j == borderLeft) {
			if (i == borderBottom) {
				renderIndices[i][j][0] = indicesStorage[detail][6][0];
				renderIndices[i][j][1] = indicesStorage[detail][6][1];
			} else if (i == borderTop) {
				renderIndices[i][j][0] = indicesStorage[detail][0][0];
				renderIndices[i][j][1] = indicesStorage[detail][0][1];
			} else {
				renderIndices[i][j][0] = indicesStorage[detail][3][0];
				renderIndices[i][j][1] = indicesStorage[detail][3][1];
			}
		} else if (i == borderBottom) {
			renderIndices[i][j][0] = indicesStorage[detail][7][0];
			renderIndices[i][j][1] = indicesStorage[detail][7][1];
		} else if (i == borderTop) {
			renderIndices[i][j][0] = indicesStorage[detail][1][0];
			renderIndices[i][j][1] = indicesStorage[detail][1][1];
		} else {
			renderIndices[i][j][0] = indicesStorage[detail][4][0];
			renderIndices[i][j][1] = indicesStorage[detail][4][1];
		}
	}

	private int exponentOf(int number) {
		for (int i = 0, exp = 1; exp <= number; i++, exp *= 2) {
			if (exp == number) {
				return i;
			}
		}

		return -1;
	}

	private void renderTerrain() {
		glEnable(GL_CULL_FACE);
		
		if(textureID != 0) {
			glBindTexture(textureType, textureID);
		}

		for (int i = 0; i < renderIndices.length; i++) {
			for (int j = 0; j < renderIndices.length; j++) {
				glBindVertexArray(basicTerrainVAO[i][j]);
				glEnableVertexAttribArray(0);
				
				glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, renderIndices[i][j][0]);
				glDrawElements(GL_TRIANGLES, renderIndices[i][j][1], GL_UNSIGNED_INT, 0);
				
				getScene().addDrawCall();
				getScene().addPolygons(renderIndices[i][j][1] / 3);
			}
		}
		
		//clean up
		glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
		glBindVertexArray(0);

		glDisable(GL_CULL_FACE);
	}
	
	public void setTexture(String texSource) {
		textureID = TextureLoader.loadTexture(texSource, GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, GL_REPEAT, GL_REPEAT, true);
		textureType = GL_TEXTURE_2D;
	}
	
	public void setTexture(String[] texSources) {
		textureID = TextureArrayLoader.loadTextureArray(texSources, GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, GL_REPEAT, GL_REPEAT, true);
		textureType = GL30.GL_TEXTURE_2D_ARRAY;
		
		Map<Integer, String> map = new HashMap<Integer, String>();
		map.put(0, "vertex_in");
		map.put(1, "texcoord_in");
		map.put(2, "normal_in");
		setShader(new Shader("shader/engine/terrain/terrainTexArray.shader", map));
		
		getShader().enable();
		getShader().sendUniform1i("texArray", 0);
		getShader().disable();
	}
	
	public float heightAt(float x, float z) {
		x = Math.max(0, Math.min(heightMap.length - 1.001f, x + width / 2));
		z = Math.max(0, Math.min(heightMap[0].length - 1.001f, z + depth / 2));
		
		float restX = x - (int) x;
		float restZ = z - (int) z;

		float height = 0;

		if (restX + restZ <= 1) {
			int posX = Math.max(0, Math.min(width - 1, (int) x));
			int posZ = Math.max(0, Math.min(depth - 1, (int) z));

			float heightOffset = heightMap[posX][posZ];
			float heightX = heightMap[posX + 1][posZ] - heightOffset;
			float heightZ = heightMap[posX][posZ + 1] - heightOffset;

			height = heightOffset + restX * heightX + restZ * heightZ;
		} else {
			int posX = Math.max(1, Math.min(width, (int) x + 1));
			int posZ = Math.max(1, Math.min(depth, (int) z) + 1);

			float heightOffset = heightMap[posX][posZ];
			float heightX = heightMap[posX - 1][posZ] - heightOffset;
			float heightZ = heightMap[posX][posZ - 1] - heightOffset;

			height = heightOffset + (1 - restX) * heightX + (1 - restZ)
					* heightZ;
		}

		return height;
	}
}
